iT邦幫忙

0

JavaScript - 來個多人ㄉ視訊聊天室

  • 分享至 

  • xImage
  •  

大家好~上次文章介紹了錄音錄影的功能,這次要來弄個多人視訊聊天室,竟然我們已經知道怎麼取得影音檔案,那麼對於聊天室而言就只少了一個東西,那就是交換資料的手段,所以這篇就來介紹一下怎麼使用瀏覽器的 API 建立 P2P 的連線,並達到交換 stream 的功能~

API 介紹

首先我們先來介紹一下這次的重點 API,分別是 RTCPeerConnectionRTCDataChannel

RTCPeerConnection

RTCPeerConnection 是建立 P2P 連線的 API,使用時會使用 new 來建立實體,建立實體之後寫入自己與對方的連線資訊,接著就會自動建立連線了

Method

基本上比較常用的就是下面幾個,不過有幾點小提醒

  • 送出方為 Offer,接收方為 Answer
  • 同步與非同步需要特別注意
  • 必須先將 stream 加入實體後再建立連線
  • 連接埠可以在建立連線後設定
// 建立實體
const peer = new RTCPeerConnection(config)
// 將 stream 加入實體
peer.addTrack(track, stream)
// 產生 Offer(返回 Promise)
peer.createOffer(offerOptions)
// 產生 Answer(返回 Promise)
peer.createAnswer()
// 設定本地端連線資訊(返回 Promise)
peer.setLocalDescription(offer)
// 設定遠端連線資訊(返回 Promise)
peer.setRemoteDescription(answer)
// 設定連線的連接埠(返回 Promise)
peer.addIceCandidate(candidate)

Event

建立實體後我們可以監聽各種事件,接著在觸發事件時就可以做相應的處理,以下為常用事件

// 找到連接埠時觸發,會有多個(TCP、UDP)
peer.addEventListener('icecandidate', e => {
  // do something...
})
// 連接埠狀態變化時觸發
peer.addEventListener('iceconnectionstatechange', e => {
  // do something...
})
// 取得對方影音時觸發
peer.addEventListener('track', e => {
  // do something...
})
// 建立 channel 後,在雙方連線時觸發
peer.addEventListener('datachannel', e => {
  // do something...
})
// 需要建立連線時觸發(初始化、stream 改變)
localPeer.addEventListener('negotiationneeded', e => {
  // do something...
})

RTCDataChannel

RTCDataChannel,是建立在 RTCPeerConnection 實體上,可以用來交換訊息或檔案,檔案目前建議使用 ArrayBuffer 來處理

Method

const peer = new RTCPeerConnection(config)
// 建立名為 channel 的 RTCDataChannel
channel = peer.createDataChannel('channel')

// 發送訊息或檔案,remoteChannel 為 datachannel 事件返回的 channel
remoteChannel.send(data)

Event

// 接收到訊息或檔案後觸發
channel.addEventListener('message', e => {
  // do something...
})

整理一下重點,這邊建立影音雙向的連線必須要有三個條件

  1. 在建立連線之前先加入 stream
  2. 雙方都設定好自己的 Offer 與對方的 Answer
  3. 設定好連線的 candidate(連接埠)

以上就是交換 stream 的方法了,那麼在開始實作之前先來看一下流程吧!

流程介紹

實作會分成後端伺服器與前端影音的部份,當前端建立連線之前我們不知道要跟誰連線,所以需要後端來幫我們傳送彼此的連線資訊,大概的流程如下:

  1. 用戶 A 進入聊天室
  2. 伺服器將用戶 A 記錄到用戶清單,並將其他用戶清單傳送給用戶 A
  3. 用戶 A 對自己以外的用戶發起連線請求,並夾帶自己的連線資訊
  4. 其他用戶收到後同意請求並回傳自己的連線資訊
  5. 用戶 A 收到其他用戶的連線資訊,雙方成功建立 P2P 連線
  6. 有人進入聊天室時重複步驟 1

這邊有兩種做法,兩種都可以達成目的

  1. 由加入者對其他用戶發起連線
  2. 由聊天室內的用戶對加入者發起連線

我們這邊採用方法 2,而伺服器為了及時知道目前有誰加入所以會使用 WebSocket 來實作

開始實作

首先我們先來定義一下前後端交換資料時的規格

  • event:事件的類別
    • init:初始化
    • request:請求連線
    • response:回應請求
    • candidate:傳送連接埠
    • close:關閉連線
  • id:用戶 ID(init
  • userList:所有聊天室內的用戶清單(init
  • sender:發送者(requestresponsecandidateclose
  • taker:接收者(requestresponsecandidateclose
  • connectionOfferAnswerrequestresponse
  • candidate:連接埠資訊(candidate

後端實作

後端我們使用 express 加上 express-ws 來實作,首先安裝套件

$ npm init -y
$ npm install express
$ npm install express-ws

安裝好了之後我們在根目錄建立一個 index.js 撰寫 node 程式,注意一下 WebSocket 傳送的資料都是字串的格式,所以我們會使用 JSON.stringifyJSON.parse 來轉換

// index.js

// 使用 express 與 express-ws
const express = require('express')
const app = express()
const expressWs = require('express-ws')(app)
// 使用根目錄檔案作為頁面
app.use(express.static(__dirname))

// 所有聊天室內的 WebSocket 實例
let websocketList = []
// 開啟 WebSocket 連線網址為 ws://localhost:3000/connection
app.ws('/connection', ws => {
  // 開啟連線時觸發
  // 使用 timestamp 當作 id
  const id = new Date().getTime()
  // 實例綁定該 id
  ws.id = id
  // 送出初始化事件
  ws.send(JSON.stringify({
    event: 'init',
	id,
	userList: websocketList.map(item => item.id)
  }))
  // 將 WebSocket 實例放入清單
  websocketList.push(ws)

  // 收到訊息時觸發
  ws.on('message', msg => {
    const data = JSON.parse(msg)

    // 找到發送者的 WebSocket 實例
    const taker = websocketList.find(item => item.id === data.taker)
    // 請求連線
    if (data.event === 'request') {
      taker.send(JSON.stringify({
        event: 'request',
        sender: data.sender,
        connection: data.connection
      }))
    }
    // 回應請求
    if (data.event === 'response') {
      taker.send(JSON.stringify({
        event: 'response',
        sender: data.sender,
        connection: data.connection
      }))
    }
    // 傳送連接埠資訊
    if (data.event === 'candidate') {
      taker.send(JSON.stringify({
        event: 'candidate',
        sender: data.sender,
        candidate: data.candidate
      }))
    }
  })
  
  // 關閉連線時觸發
  ws.on('close', () => {
    // 將 WebSocket 實例從清單移除
    websocketList = websocketList.filter(item => item !== ws)
    // 通知其他人將該連線關閉
	websocketList.forEach(client => {
      client.send(JSON.stringify({
        event: 'close',
        sender: ws.id
      }))
    })
  })
})

app.listen(3000)

這樣後端程式就完成了,基本上只是做一些資訊的傳送而已

前端實作

首先簡單弄個版~

※ 提醒一下,這篇使用 chrome 測試,不同瀏覽器的支援度與安全性設定會有些不同

<!-- index.html -->

<h1>聊天室</h1>
<button id="camera">視訊鏡頭</button>
<button id="screen">分享螢幕</button>
<button id="close">關閉分享</button>
<input id="textInput" type="text">
<button id="submit">送出訊息</button>
<input id="fileInput" type="file">

<div class="wrap">
  <div>
    <video id="video" autoplay></video>
  </div>
</div>
video {
  height: 100%;
  width: 100%;
}
.wrap {
  display: flex;
  flex-wrap: wrap;
}
.wrap > div {
  width: 25%;
  height: 250px;
  background-color: #000;
  margin: 0.5rem 0.5rem 0;
}

版面會長這樣,不是很美觀將就一下啦QQ
版面

接著我們先看一下變數與大概的結構

const video = document.querySelector('#video')
const cameraBtn = document.querySelector('#camera')
const screenBtn = document.querySelector('#screen')
const closeBtn = document.querySelector('#close')
const textInput = document.querySelector('#textInput')
const submitBtn = document.querySelector('#submit')
const fileInput = document.querySelector('#fileInput')

// stream 檔案
let cameraStream
let screenStream

// mediaDevices 的設定
const constraints = { audio: true, video: true }
// offer 的設定
const offerOptions = { offerToReceiveAudio: true,  offerToReceiveVideo: true }

// 自己的 ID
let myId
// 所有人員的清單
let userList = []

cameraBtn.addEventListener('click', () => {
  // 取得視訊鏡頭的 stream
})
screenBtn.addEventListener('click', () => {
  // 取得螢幕分享的 stream
})
closeBtn.addEventListener('click', () => {
  // 關閉視訊鏡頭與螢幕分享的 stream
})
submitBtn.addEventListener('click', () => {
  // 送出文字訊息
})
fileInput.addEventListener('change', () => {
  // 送出檔案
})

function init() {
  // 建立 WebSocket 連線
  const ws = new WebSocket('ws://localhost:3000/connection')

  // 收到訊息觸發該事件
  ws.addEventListener('message', async e => {
    // 轉換字串訊息為物件
    const data = JSON.parse(e.data)
    
    // 找到送出訊息的人(init 以外使用)
    const sender = userList.find(user => user.id === data.sender)
    
    // 第一次開啟 WebSocket 連線時觸發
    if (data.event === 'init') {
      // do something...
    }
    
    // 收到別人發出的請求時觸發
    if (data.event === 'request') {
      // do something...
    }
    
    // 收到回覆時觸發
    if (data.event === 'response') {
      // do something...
    }
    
    // 有人傳送連接埠時觸發
    if (data.event === 'candidate') {
      // do something...
    }
    
    // 有人離開時觸發
    if (data.event === 'close') {
      // do something...
    }
  })
}

init()

接著我們先來填入各個事件該做的事情吧

cameraBtn click

cameraBtn.addEventListener('click', () => {
  if (cameraStream) return
  // 取得視訊鏡頭的 stream
  navigator.mediaDevices.getUserMedia(constraints).then(stream => {
    // 將本來螢幕分享的 stream 清除
    if (screenStream) {
      screenStream.getTracks().forEach(track => {
        track.stop()
      })
      screenStream = null
    }
    // 設定視訊鏡頭的 stream 到畫面
    cameraStream = stream
    video.srcObject = stream

    userList.forEach(user => {
      if (!user.peer) return
      // peer 移除之前的 stream
      user.peer.getSenders().forEach(sender => {
        user.peer.removeTrack(sender)
      })
      // peer 新增新的 stream
      stream.getTracks().forEach(track => {
        user.peer.addTrack(track, stream)
      })
    })
  })
})

screenBtn click

screenBtn.addEventListener('click', () => {
  if (screenStream) return
  // 取得螢幕分享 stream
  navigator.mediaDevices.getDisplayMedia(constraints).then(stream => {
    if (cameraStream) {
      // 將本來視訊鏡頭的 stream 清除
      cameraStream.getTracks().forEach(track => {
        track.stop()
      })
      cameraStream = null
    }
    // 設定螢幕分享的 stream 到畫面
    screenStream = stream
    video.srcObject = stream

    userList.forEach(user => {
      if (!user.peer) return
      // peer 移除之前的 stream
      user.peer.getSenders().forEach(sender => {
        user.peer.removeTrack(sender)
      })
      // peer 新增新的 stream
      stream.getTracks().forEach(track => {
        user.peer.addTrack(track, stream)
      })
    })
  })
})

closeBtn click

closeBtn.addEventListener('click', () => {
  if (screenStream) {
    // 將螢幕分享的 stream 清除
    screenStream.getTracks().forEach(track => {
      track.stop()
    })
    screenStream = null
  }
  if (cameraStream) {
    // 將視訊鏡頭的 stream 清除
    cameraStream.getTracks().forEach(track => {
      track.stop()
    })
    cameraStream = null
  }

  // 所有的 peer 移除之前的 stream
  userList.forEach(user => {
    user.peer.getSenders().forEach(sender => {
      user.peer.removeTrack(sender)
    })
  })
})

submitBtn click

submitBtn.addEventListener('click', () => {
  const value = textInput.value
  if (!value) return
  // 所有的 peer 送出文字訊息
  userList.forEach(user => {
    if (!user.channel) return
    user.channel.send(value)
  })
})

fileInput change

fileInput.addEventListener('change', e => {
  const file = e.target.files[0]
  if (!file) return
  // 這邊設定僅接受 jpeg 格式
  if (file.type !== 'image/jpeg') return
  // 將檔案轉換成 ArrayBuffer
  const reader = new FileReader()
  reader.readAsArrayBuffer(file)
  reader.onload = e => {
    // 所有的 peer 送出 ArrayBuffer
    userList.forEach(user => {
      if (!user.channel) return
      user.channel.send(e.target.result)
    })
  }
})

WebSocket event - init

// 第一次開啟 WebSocket 連線時觸發
if (data.event === 'init') {
  // 設定自己的 ID
  myId = data.id
  // 設定所有人員的清單
  userList = data.userList.map(id => ({ id, peer: null, channel: null }))
  // 對所有人員發起連線
  userList.forEach(async user => {
    user.peer = new RTCPeerConnection()
    user.peer.addEventListener('icecandidate', e => {
      // 傳送連接埠資訊
      ws.send(JSON.stringify({
        event: 'candidate',
        sender: myId,
        taker: user.id,
        candidate: e.candidate 
      }))
    })
    user.peer.addEventListener('connectionstatechange', e => {
      const currentVideo = document.querySelector(`#video_${user.id} > video`)
      if (currentVideo) return
      // 初始化畫面 video
      const div = document.createElement('div')
      div.id = `video_${user.id}`
      const video = document.createElement('video')
      video.autoplay = true
      div.appendChild(video)
      const wrap = document.querySelector('.wrap')
      wrap.appendChild(div)
    })
    user.peer.addEventListener('track', e => {
      // 將 stream 顯示於畫面
      const currentVideo = document.querySelector(`#video_${user.id} > video`)
      currentVideo.srcObject = e.streams[0]
    })
    user.peer.addEventListener('removestream', e => {
      // 將 stream 從畫面移除
      const currentVideo = document.querySelector(`#video_${user.id} > video`)
      currentVideo.srcObject = null
    })
    user.peer.addEventListener('datachannel', e => {
      // 將對方的 channel 寫入物件
      user.channel = e.channel
    })
    user.peer.addEventListener('negotiationneeded', async e => {
      // 連接尚未建立時不動作
      if (user.peer.connectionState !== 'connected') return
      // 重新發出請求並建立連線
      const offer = await user.peer.createOffer(offerOptions)
      await user.peer.setLocalDescription(offer)
      ws.send(JSON.stringify({
        event: 'request',
        sender: myId,
        taker: user.id,
        connection: offer
      }))
    })
    // 建立 DataChannel
    channel = user.peer.createDataChannel('channel')
    channel.addEventListener('message', e => {
      if (typeof e.data === 'object') {
        // 收到檔案時詢問後下載該檔案
        const message = `是否下載 ${user.id} 提供的檔案?`
        const result = confirm(message)
        if (!result) return
        const blob = new Blob([e.data], { type: 'image/jpeg' })
        const downloadLink = document.createElement('a')
        downloadLink.href = URL.createObjectURL(blob)
        downloadLink.download = 'download'
        downloadLink.click()
        URL.revokeObjectURL(downloadLink.href)
      } else {
        // 收到文字時使用 alert 印出
        const message = `${user.id}: ${e.data}`
        alert(message)
      }
    })

    if (cameraStream) {
      // 將視訊鏡頭的 stream 加入 peer
      cameraStream.getTracks().forEach(track => {
        user.peer.addTrack(track, cameraStream)
      })
    }
    if (screenStream) {
      // 將螢幕分享的 stream 加入 peer
      screenStream.getTracks().forEach(track => {
        user.peer.addTrack(track, screenStream)
      })
    }

    // 發出請求並建立連線
    const offer = await user.peer.createOffer(offerOptions)
    await user.peer.setLocalDescription(offer)
    ws.send(JSON.stringify({
      event: 'request',
      sender: myId,
      taker: user.id,
      connection: offer
    }))
  })
}

WebSocket event - request

// 收到別人發出的請求時觸發
if (data.event === 'request') {
  if (!sender) {
    // 新成員加入
    // 建立該人員的資訊並放入清單
    const user = { id: data.sender, peer: null, channel: null }
    userList.push(user)
    user.peer = new RTCPeerConnection()
    user.peer.addEventListener('icecandidate', e => {
      // 傳送連接埠資訊
      ws.send(JSON.stringify({
        event: 'candidate',
        sender: myId,
        taker: user.id,
        candidate: e.candidate 
      }))
    })
    user.peer.addEventListener('connectionstatechange', e => {
      const currentVideo = document.querySelector(`#video_${user.id} > video`)
      if (currentVideo) return
      // 初始化畫面 video
      const div = document.createElement('div')
      div.id = `video_${user.id}`
      const video = document.createElement('video')
      video.autoplay = true
      div.appendChild(video)
      const wrap = document.querySelector('.wrap')
      wrap.appendChild(div)
    })
    user.peer.addEventListener('track', e => {
      // 將 stream 顯示於畫面
      const currentVideo = document.querySelector(`#video_${user.id} > video`)
      currentVideo.srcObject = e.streams[0]
    })
    user.peer.addEventListener('removestream', e => {
      // 將 stream 從畫面移除
      const currentVideo = document.querySelector(`#video_${user.id} > video`)
      currentVideo.srcObject = null
    })
    user.peer.addEventListener('datachannel', e => {
      // 將對方的 channel 寫入物件
      user.channel = e.channel
    })
    user.peer.addEventListener('negotiationneeded', async e => {
      // 連接尚未建立時不動作
      if (user.peer.connectionState !== 'connected') return
      // 重新發出請求並建立連線
      const offer = await user.peer.createOffer(offerOptions)
      await user.peer.setLocalDescription(offer)
      ws.send(JSON.stringify({
        event: 'request',
        sender: myId,
        taker: user.id,
        connection: offer
      }))
    })
    // 建立 DataChannel
    channel = user.peer.createDataChannel('channel')
    channel.addEventListener('message', e => {
      if (typeof e.data === 'object') {
        // 收到檔案時詢問後下載該檔案
        const message = `是否下載 ${user.id} 提供的檔案?`
        const result = confirm(message)
        if (!result) return
        const blob = new Blob([e.data], { type: 'image/jpeg' })
        const downloadLink = document.createElement('a')
        downloadLink.href = URL.createObjectURL(blob)
        downloadLink.download = 'download'
        downloadLink.click()
        URL.revokeObjectURL(downloadLink.href)
      } else {
        // 收到文字時使用 alert 印出
        const message = `${user.id}: ${e.data}`
        alert(message)
      }
    })

    if (cameraStream) {
      // 將視訊鏡頭的 stream 加入 peer
      cameraStream.getTracks().forEach(track => {
        user.peer.addTrack(track, cameraStream)
      })
    }
    if (screenStream) {
      // 將螢幕分享的 stream 加入 peer
      screenStream.getTracks().forEach(track => {
        user.peer.addTrack(track, screenStream)
      })
    }

    // 設定該 peer 的連線資訊並回覆自己的連線資訊
    await user.peer.setRemoteDescription(data.connection)
    const answer = await user.peer.createAnswer(offerOptions)
    await user.peer.setLocalDescription(answer)
    ws.send(JSON.stringify({
      event: 'response',
      sender: myId,
      taker: user.id,
      connection: answer
    }))
  } else {
    // 設定該 peer 的連線資訊並回覆自己的連線資訊
    await sender.peer.setRemoteDescription(data.connection)
    const answer = await sender.peer.createAnswer(offerOptions)
    await sender.peer.setLocalDescription(answer)
    ws.send(JSON.stringify({
      event: 'response',
      sender: myId,
      taker: sender.id,
      connection: answer
    }))
  }
}

WebSocket event - response

// 收到回覆時觸發
if (data.event === 'response') {
  // 設定該 peer 的連線資訊
  sender.peer.setRemoteDescription(data.connection)
}

WebSocket event - candidate

// 有人傳送連接埠時觸發
if (data.event === 'candidate') {
  // 設定該 peer 的連接埠
  sender.peer.addIceCandidate(data.candidate)
}

WebSocket event - close

// 有人離開時觸發
if (data.event === 'close') {
  // 清單移除離開者
  userList = userList.filter(user => user !== sender)
  // 關閉該連線
  sender.peer.close()
  // 移除離開者的畫面
  const videoDiv = document.querySelector(`#video_${data.sender}`)
  if (videoDiv) videoDiv.remove()
}

WebSocket event - candidate

// 有人傳送連接埠時觸發
if (data.event === 'candidate') {
  // 設定該 peer 的連接埠
  sender.peer.addIceCandidate(data.candidate)
}

接著就可以正常運作啦~灑花
成品

結語

終於做完啦~聊天室是一個很貼近日常的應用,做完真的是成就感滿滿阿,學完之前的 mediaDevices 再來看聊天室是不是很簡單呢(才怪),基本上最重要的就是搞清楚設定 offer 與 answer 的順序,其他的就不算什麼了~


圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言